build: publish @cardstack/bxl to npm from the monorepo - #5810
Conversation
Merging a change to anything the tarball ships now publishes a prerelease under the `unstable` dist-tag, with the version taken from the merged PR's conventional-commit prefix; a stable version under `latest` is cut deliberately by running the workflow with `confirm = promote`. The published package carries JavaScript rather than the raw erasable TypeScript in-repo consumers read: an installed package sits inside node_modules, where Node refuses to strip types. `pnpm build` emits `dist/` with declarations and source maps, and `publishConfig.exports` points the published package there while the ordinary exports map keeps pointing at `src/`. `pnpm verify:package` packs the artifact, installs it outside the monorepo under npm's hoisted layout, and checks it as a consumer would — every published subpath loads under plain Node, a formula evaluates, a lazy formula chunk resolves, and a compiler finds declarations for the whole surface. It runs pre-merge in CI and again against the registry after each publish. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prefix on a PR title now drives two npm publishes, not one. The skill and AGENTS.md section that told an author when a prefix is required — and when it is noise — cover both packages, with the per-package prefix list, classifier, and workflows named side by side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 529d9ea24b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Publishes @cardstack/bxl from the monorepo as compiled JavaScript with declarations, automated versioning, and artifact verification.
Changes:
- Adds package build and consumer-install verification.
- Adds prerelease/stable release tooling and tests.
- Adds CI publishing, title validation, and release documentation.
Reviewed changes
Copilot reviewed 25 out of 26 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
pnpm-lock.yaml |
Locks release-tool dependencies. |
packages/bxl/tsconfig.json |
Excludes generated output. |
packages/bxl/tsconfig.build.json |
Configures publish artifact emission. |
packages/bxl/tests/unit/release-cli.ts |
Tests release decisions and helpers. |
packages/bxl/tests/unit/bxl-build-info.ts |
Checks manifest/runtime version parity. |
packages/bxl/src/index.ts |
Generalizes version documentation. |
packages/bxl/scripts/verify-package.ts |
Verifies installed npm artifacts. |
packages/bxl/scripts/set-version.ts |
Synchronizes package versions. |
packages/bxl/scripts/release-prefixes.json |
Defines version-bump prefixes. |
packages/bxl/scripts/README.md |
Documents package scripts. |
packages/bxl/scripts/promote-changelog.ts |
Promotes changelog entries. |
packages/bxl/scripts/next-unstable-version.ts |
Selects prerelease counters. |
packages/bxl/scripts/compute-release.ts |
Computes automated releases. |
packages/bxl/scripts/build.ts |
Builds and validates dist. |
packages/bxl/README.md |
Documents installation and releases. |
packages/bxl/package.json |
Configures build, exports, and publishing. |
packages/bxl/CHANGELOG.md |
Records npm publishing support. |
packages/bxl/.gitignore |
Ignores generated artifacts. |
packages/bxl/.eslintignore |
Excludes generated artifacts from linting. |
AGENTS.md |
Extends published-package title guidance. |
.github/workflows/ci.yaml |
Verifies packed BXL artifacts. |
.github/workflows/bxl-publish.yml |
Adds BXL publishing workflows. |
.github/workflows/bxl-pr-title.yml |
Enforces BXL release titles. |
.github/workflows/boxel-cli-publish.yml |
Shares release concurrency. |
.claude/skills/published-package-pr-title/SKILL.md |
Adds shared title guidance. |
.claude/skills/boxel-cli-pr-title/SKILL.md |
Removes superseded guidance. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The changed-file check read the checked-out tip, which is `main` and may already carry another release commit by the time a run gets there — the version bump would then be computed from someone else's diff and the release silently skipped. The push's own commit range now bounds it, so what a run classifies is what its own event introduced. The stable release a prerelease series builds on was only recoverable while the manifest still held it, which stopped being true the moment the first prerelease was committed: every merge after that had no base to bump and failed. The run that consumes the last copy now tags it. A concurrency group keeps one pending run and cancels it when another arrives, so back-to-back merges could discard a release that nothing retries; the group now queues them. It is also this package's own group again — serializing across packages only bought a wait, and every wait is a checkout of a main that has moved. The race that sharing would have prevented is handled where it happens: the release commit is pushed before anything is tagged, and replayed if an ordinary merge lands first. A catalog entry this package depends on resolves into the published manifest, so moving one now releases, and only when the entry belongs to this package. Also: the published exports map is derived from the development one and compared entry for entry, rather than by subpath alone — a subpath wired to the wrong sibling read as correct. The emitted-tree check reads imports from the syntax tree, so a doc comment naming a `.ts` path no longer fails the build. `files` must ship `src`, which the source maps and NOTICE.md's attributions resolve against. A manual republish directly after a promotion takes the next patch, rather than a prerelease that sorts below the release it names. And the publish steps no longer set an npm token env var that pnpm does not read: authentication is Trusted Publishing, which the comment now says, along with why the first version of a package cannot come from CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This comment was marked as resolved.
This comment was marked as resolved.
Its concurrency group was renamed when bxl's releases were going to share it. They no longer do — bxl has its own group — so the rename bought nothing and left that workflow's comment describing an arrangement that isn't there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The version the package carried predates everything accumulated since the port — two additions, four behavior changes, four fixes, and two removals. Publishing that content under the older number would have described it by a changelog entry written for something else, so the version moves and the entries move with it, out of Unreleased and under the version that ships them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…to-npm # Conflicts: # packages/bxl/CHANGELOG.md
Realm Server Test Results0 tests 0 ✅ 0s ⏱️ Results for commit a62764c. |
@cardstack/bxlhas an npm identity but nothing has ever gone out under it — the library was consumed as a per-realm bundle, and since the port it has been a workspace package only. This wires up publishing from the monorepo, following boxel-cli's shape: a conventional-commit PR title decides the version, merging publishes a prerelease, and cutting a stable release stays a deliberate act.The artifact can't be the sources
The package's
exportsmap points atsrc/*.ts, which works for both in-repo consumers — the host bundles the TypeScript, and the realm-server reaches it through the pnpm workspace link, whose realpath sits outsidenode_modulesso Node type-strips it. An installed package lands insidenode_modules, where Node refuses to strip types (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). Published as-is, the package would be dead on arrival.So
pnpm build(scripts/build.ts) emits JavaScript plus declarations and source maps intodist/, andpublishConfig.exportspoints the published package there while the ordinary map keeps pointing atsrc/— pnpm swaps the map in the tarball only, so nothing about local development changes.prepackruns the build, so no pack or publish can ship a stale or missingdist/. The tarball carriesdist/andsrc/both: the maps resolve to real TypeScript, and NOTICE.md's attribution references intosrc/stay valid.Two things the compiler leaves behind, which the build fixes and then asserts:
rewriteRelativeImportExtensionsrewrites./x.ts→./x.jsin emitted JavaScript (dynamic imports included, so the lazy formula chunks resolve) but not in declarations. The build rewrites those through the parsed syntax tree, so a.tspath in a doc comment isn't touched. TypeScript itself copes with a.tsspecifier by substituting the sibling.d.ts— this is about the declarations describing the tree that actually shipped./// <reference path="…/types/*.d.ts" />shims for the untypedbessel/jstat/validatorpackages get copied into the emitted JavaScript, where the path resolves to nothing. They're blanked rather than deleted so source-map line numbering still lines up.The build then refuses to finish if the two exports maps describe different subpaths, if a published target doesn't exist, or if anything in
dist/still names a.tsfile.Verifying the thing we ship
Every existing suite reads
src/, so none of them says anything about the tarball.pnpm verify:packagepacks it, installs it with npm into a throwaway project outside the monorepo (npm, not pnpm, for the hoisted layout a consumer gets), and checks it from there: every subpath in the published exports map loads under plain Node, a formula evaluates end to end, a lazy chunk resolves, the TextMate grammar reads as JSON, the version the build reports matches the manifest, and a consumer's compiler —nodenext,strict,skipLibCheckoff — resolves declarations across the whole surface. It runs pre-merge in the BXL CI job and again against the registry after each publish.I confirmed that last check has teeth by deleting one declaration from an install: 9 errors. It does not catch the
.ts-specifier form (TypeScript's substitution absorbs that) — the build's own assertion is what enforces it, and the comments say so rather than claiming otherwise.When a merge publishes
scripts/compute-release.tsneeds both a bumpable prefix and a change to something the tarball ships. Afix:touching only test suites, benchmarks, or the lint rules publishes nothing — no version burned on an artifact that didn't move. Already on a prerelease, the base is the accumulation since the last stable tag, so three fixes in a row give.0,.1,.2of one patch version rather than three patch versions, and a laterfeat:escalates the base. Counters come from npm rather than git, since the manual publish path deliberately doesn't commit its bump.tests/unit/release-cli.tscovers those decisions — 65 checks, no git or npm involved.The version lives in two places:
package.jsonandVERSIONinsrc/index.ts, which is what a consumer reads throughBXL_BUILD_INFO.scripts/set-version.tsis the only thing that sets it and sets both; the build-info suite fails on drift; and the published-artifact check asserts the shipped package agrees with itself.Promoting to stable (
confirm = promote) strips the suffix, closes out the CHANGELOG's[Unreleased]section under the new version, and uses that section as the release notes — so it stops rather than shipping a version heading with nothing under it.When a merge does not publish
compute-release.tsneeds a bumpable prefix and a change to something the tarball ships, so afix:touching only test suites or CI config publishes nothing. Two inputs are deliberately not taken from the checked-out tree:github.event.before..github.sha, not from the checkout'sHEAD. The workflow checks outmain, whose tip can already carry another release commit by the time a run reaches it — classifying that diff would silently skip the release.bootstrapStableTag). Without this, every merge after the first publish had no base to bump and failed.Concurrency is this package's own group with
queue: max: a group keeps one pending run by default and cancels it when another arrives, which would discard a release nothing retries. The cross-workflow push race is handled where it happens instead of by serializing — the release commit is pushed before anything is tagged, and replayed onto main if an ordinary merge lands first.The workspace catalog counts as shipped surface, since
catalog:specifiers resolve into the published manifest — but only when the entries that moved are ones this package depends on.Notes for review
_npmUser.trustedPublisherfor@cardstack/boxel-cli@0.5.0, and there is no repo-levelNPM_TOKEN. A trusted-publisher rule can only be added to a package that already exists, and@cardstack/bxlhas never been published (404, absent from the 88 packages in the scope). So the first version goes out by hand —pnpm publishfrompackages/bxl, nevernpm publish, since pnpm is what resolves thecatalog:specifiers — and the rule forbxl-publish.ymlis registered after that. The publish steps no longer setNODE_AUTH_TOKEN: nothing maps it to a credential here (no.npmrc, nosetup-node), and pnpm 11 does not read it.build:, so merging it publishes nothing. A bumpable prefix would have cut a version on merge, and that first automated publish has nothing to authenticate with yet — it would fail after committing the version bump and pushing two tags.build:classifies as no release, so the workflow runs and no-ops, and the first version goes out through the steps below.HEAD^anchoring has the same moved-main exposure for back-to-back merges; that predates this PR and is left alone rather than changing another package's release behavior here.Publishing the first version
The package does not exist on npm yet, and a trusted-publisher rule can only be
attached to one that does — so the first version is published by hand, once.
Everything after it comes from the workflow.
The version to publish is 0.6.0, already committed here: the tree had
accumulated fourteen changelog entries since the port — two additions, four
behavior changes, four fixes, and two removals — so publishing it under the
0.5.1 the package carried would have described it by an entry written for
something else.
[Unreleased]is closed out under## [0.6.0]to match.Merge this PR. Safe to do at any time: the
build:title classifies themerge as no release, so the workflow runs and no-ops.
Publish from a clean
main, not from a branch — npm should hold whatmainholds, and pnpm'spublish-branchguard checks exactly this:pnpm publish, nevernpm publish— pnpm is what resolves thecatalog:dependency specifiers to real ranges. npm would ship literalcatalog:and every install would fail.prepackbuildsdist/first. Theregistry will ask for your second factor.
Tag the release, so the tag names the commit that shipped:
git tag -a bxl-v0.6.0 -m bxl-v0.6.0 && git push origin bxl-v0.6.0The workflow creates this tag itself if it is missing, but pointed at
whatever
main's tip is at that moment rather than at the release commit.Register the trusted publisher: npmjs.com →
@cardstack/bxl→ Settings →Trusted Publisher → GitHub Actions, repo
cardstack/boxel, workflowbxl-publish.yml. This is the same arrangement boxel-cli publishes under,and it is why both publish paths live in one workflow file.
From then on, a merge touching what the tarball ships publishes
0.6.x-unstable.<n>under theunstabledist-tag, with provenance and nocredential held anywhere.
lateststays at 0.6.0 until someone runs theworkflow with
confirm = promote.Verification
70/70 BXL suites (84 checks on the release decisions, 14 on the published exports map), lint clean,
verify:packagegreen off a realpnpm pack— an 864K tarball whose manifest carries the derived exports map and thecatalog:specifiers resolved to real ranges.Both new workflow files, plus
ci.yaml, validate against the official GitHub workflow schema — worth doing becausebxl-publish.ymlonly triggers on main, so no PR check would catch a bad key in it.The build's assertions were checked by mutation rather than by reading: swapping two exports targets, dropping
srcfromfiles, and adding a subpath to one map only each fail with a specific message, while a doc comment mentioning a.tspath now builds clean (it previously failed, since the check was a text search).The two red
Host Testsshards are the documentedcreate realm usersSynapse-container flake — the step fails before any test runs, and nothing here touches that path.